# Copyright (C) 2019 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0

import re
import subprocess
import sys
import logging
import gzip
from typing import Set, List

usage = """
Usage:  parse_build_log.py [log_file]

Parses the output of COIN test runs and prints short summaries of compile
errors and test fails for usage as gerrit comment. Takes the file name
(either text or compressed .gz file).
"""

# Match the log prefix "agent:2019/06/04 12:32:54 agent.go:262:"
# and alternatively prefix with column(?): "agent:2019/06/04 12:32:54 agent.go:262: 53: "
prefix_re = re.compile(r'^agent:[\d :/]+\w+\.go:\d+: (\d+: )?')

# Match QTestlib output
start_test_re = re.compile(r'^\*{9} Start testing of \w+ \*{9}$')
end_test_re = re.compile(r'Totals: \d+ passed, (\d+) failed, \d+ skipped, \d+ blacklisted, \d+ms')
end_test_crash_re = re.compile(r'\d+/\d+\sTest\s#\d+:.*\*\*\*Failed.*')
end_test_timeout_re = re.compile(r'Test #\d+: .+ \.*\*{3}Timeout \d+\.\d+ sec')

# Match make or cmake errors
make_error_re = re.compile(r'make\[.*Error \d+$')
cmake_error_re = re.compile(r'CMake Error')

# Failed to install tests archive
nosource_re = re.compile(r"No sources for \"https?://(\d+\.\d+\.\d+\.\d+):(\d+)") # failed to install tests archive


def read_file(file_name):
    """
    Read a text file into a list of of chopped lines.
    """
    opener = gzip.open if file_name.endswith(".gz") else open
    with opener(file_name, mode="rt") as f:
        return [prefix_re.sub('', l.rstrip()) for l in f.readlines()]


def is_compile_error(line):
    """
    Return whether a line is an error from one of the common compilers
    (g++, MSVC, Python)
    """
    if any(e in line for e in (": error: ", ": error C", "SyntaxError:", "NameError:", "fatal error")):
        # Ignore error messages in debug output
        # and also ignore the final ERROR building message, as that one would only print sccache
        # output
        if not ("QDEBUG" in line or "QWARN" in line):
            logging.debug(f"===> Found error in line \n{line}\n")
            return True
    has_error = make_error_re.match(line) or cmake_error_re.search(line)
    if has_error:
        logging.debug(f"===> Found error in line \n{line}\n")
    return has_error


def print_failed_test(lines, start, end, already_known_errors):
    """
    For a failed test, print 3 lines following the FAIL!/XPASS and
    header/footer.
    """
    last_fail = -50
    # Print 3 lines after a failure
    header = '\n{}: {}'.format(start, lines[start])
    test_result = []
    for i in range(start + 1, end):
        line = lines[i]
        if 'FAIL!' in line or 'XPASS' in line or '***Failed' in line:
            # the format is FAIL! class::method(n) <info>
            # with n being the time that the test has been repeated. To deduplicate, we need to
            # remove n. n should be < 9, so %d is sufficient to match
            # The first test might not have a number at all, but then the regex will also just
            # ignore it
            adjusted_line = re.sub(r'\(\d\)', '', line)
            if not adjusted_line in already_known_errors:
                already_known_errors.add(adjusted_line)
                last_fail = i
        if i - last_fail < 4:
            test_result.append(line)
    if test_result:
        print(header)
        print("\n".join(test_result))
        print('{}\n'.format(lines[end]))

def is_fatal_timeout(line: str) -> bool:
    if "Killed process: No output received (timeout" in line:
        return True
    if end_test_timeout_re.match(line):
        return True

def print_line_with_context(start_line_number: int, context_before: int, lines: List[str]):
    start = max(0, start_line_number - context_before)
    sys.stdout.write('\n{}: '.format(start))
    for e in range(start, start_line_number + 1):
        print(lines[e])

def parse(lines):
    """
    Parse the output and print compile/test errors.
    """
    test_start_line = -1
    within_configure_tests = False
    already_known_errors: Set[str] = set()
    # used to skip CMake output which contains information about failed configure tests
    within_cmake_output: bool = False
    # used to skip sccache output
    for i, line in enumerate(lines):
        if within_cmake_output:
            if "======== End CMake output ======" in line:
                within_cmake_output = False
        elif within_configure_tests:
            if line == 'Done running configuration tests.':
                within_configure_tests = False
        elif test_start_line >= 0:
            end_match = end_test_re.match(line)
            if end_match:
                fails = int(end_match.group(1))
                if fails:
                    print_failed_test(lines, test_start_line, i, already_known_errors)
                test_start_line = -1
            elif end_test_crash_re.match(line):
                logging.debug(f"===> test crashed {line} {test_start_line} {i}")
                print_failed_test(lines, test_start_line, i, already_known_errors)
                test_start_line = -1
            elif is_fatal_timeout(line):
                logging.debug("===> Matched fatal timeout")
                print("While running tests, a timeout occurred: ")
                print_line_with_context(i, 10, lines)
        # Do not report errors within configuration tests
        elif line == 'Running configuration tests...':
            within_configure_tests = True
        elif "======== CMake output     ======" in line:
            within_cmake_output = True
        elif start_test_re.match(line):
            logging.debug("===> Matched test")
            test_start_line = i
        elif is_compile_error(line):
            logging.debug("===> Matched compile error")
            print_line_with_context(i, 10, lines)
        elif nosource_re.match(line):
            print_line_with_context(i, 10, lines)

if __name__ == '__main__':
    if sys.version_info[0] != 3:
        print("This script requires Python 3")
        sys.exit(-2)
    if len(sys.argv) < 2:
        print(usage)
        sys.exit(-1)
    file_name = sys.argv[1]
    try:
        should_log = sys.argv[2] == "-debug"
        if should_log:
            logging.basicConfig(level=logging.DEBUG)
    except IndexError:
        pass

    lines = read_file(file_name)
    parse(lines)
